/**
 * Copyright (C) 2010 altuure <altuure [AT] gmail [DOT] com> http://www.altuure.com/projects/yagdao
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.altuure.yagdao.jpa;

import java.io.Serializable;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.persistence.EntityManager;
import javax.persistence.NonUniqueResultException;
import javax.persistence.Query;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Expression;
import javax.persistence.criteria.From;
import javax.persistence.criteria.Path;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Selection;

import com.altuure.yagdao.SearchResultList;
import com.altuure.yagdao.YFetch;
import com.altuure.yagdao.YLimit;
import com.altuure.yagdao.YMethod;
import com.altuure.yagdao.YOperator;
import com.altuure.yagdao.YPage;
import com.altuure.yagdao.YParameter;
import com.altuure.yagdao.common.GenericDAOFactory;
import com.altuure.yagdao.common.Utils;

/**
 * generic DAO factory for JPA.
 * @author altuure
 */
public class GenericJpaDAOFactory extends GenericDAOFactory {

    /**
     * created DAO bean instance
     */
    private GenericJpaDAO genericDAO;
    /**
     * injected entity manager
     */
    private EntityManagerAccessor entityManagerAccessor;

    public GenericJpaDAOFactory() {
        setTargetDAOImpl(GenericJpaDAO.class);
    }

    public Class getObjectType() {
        if (daoClass == null)
            try {
                initClass();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        return genericDAO.getClass();
    }

    public Object getObject() throws Exception {
        return genericDAO;
    }

    public boolean isSingleton() {
        return true; // todo : implement here
    }

    /**
     * create proxy class
     */
    public void initClass() {
        InvocationHandler h = initHandler();
        if (h == null)
            return;
        genericDAO = (GenericJpaDAO) Utils.createInstance(h, targetDAOImpl, targetInterfaces);
        this.daoClass = genericDAO.getClass();
        initObjectClass();

    }

    /**
     * init DAO bean
     * @throws Exception init exception
     */
    protected void initDAO() throws Exception {
        initClass();
        genericDAO.setObjectClass(objectClass);
        genericDAO.setEntityManagerAccessor(entityManagerAccessor);

    }

    /*
     * (non-Javadoc)
     * 
     * @see
     * com.altuure.yagdao.common.GenericDAOFactory#criteriaMethod(java.lang.
     * Object, java.lang.reflect.Method, java.lang.Object[])
     */
    protected Object criteriaMethod(Object obj, Method method, Object[] args) {

        CriteriaBuilder criteriaBuilder = getEntityManager().getCriteriaBuilder();
        CriteriaQuery<Object> criteriaQuery = criteriaBuilder.createQuery();
        Root from = criteriaQuery.from(objectClass);
        CriteriaQuery<Object> select = selectResult(criteriaQuery, from, method);
        YPage paging = findDAOPage(method, args);

        Map<String, From> joins = setPredicates(method, args, from, criteriaBuilder, criteriaQuery);
        fetchStatements(from, method);
        /*
         * if (paging == null) { TypedQuery<Object> typedQuery =
         * getEntityManager().createQuery(select); return new
         * SearchResultList((List) typedQuery.getResultList()); }
         */
        genericDAO.setOrders(select, paging, criteriaBuilder, from, joins);
        TypedQuery<Object> typedQuery = getEntityManager().createQuery(select);
        genericDAO.setLimits(typedQuery, paging);

        if (SearchResultList.class.isAssignableFrom(method.getReturnType())) {
            long totalCount = (Long) countMethod(obj, method, args);
            return new SearchResultList((List) typedQuery.getResultList(), totalCount, paging);
        } else {
            return typedQuery.getResultList();
        }

    }

    private EntityManager getEntityManager() {
        return entityManagerAccessor.getEntityManager();
    }

    /**
     * @param method handled method
     * @param args arguments
     * @param from root
     * @param criteriaBuilder criteria builder
     * @param criteriaQuery cq
     * @return predicate list
     */
    public Map<String, From> setPredicates(Method method, Object[] args, Root from, CriteriaBuilder criteriaBuilder,
            CriteriaQuery<Object> criteriaQuery) {
        Map<String, From> joins = new HashMap();

        Predicate rootPredicate = null;
        Annotation[][] parameterAnnotations = method.getParameterAnnotations();
        for (int i = 0; i < parameterAnnotations.length; i++) {
            Annotation[] parameterAnnotation = parameterAnnotations[i];
            if (parameterAnnotation == null)
                continue;
            Object value = args[i];
            if (value == null)
                continue;
            if (value instanceof String) {
                String s = (String) value;
                if (Utils.isEmpty((String) s))
                    continue;
            }

            for (Annotation annotation1 : parameterAnnotation) {
                if (!(annotation1 instanceof YParameter))
                    continue;
                YParameter daoMethodParameter = (YParameter) annotation1;
                YOperator operator = daoMethodParameter.operator();
                String s = daoMethodParameter.value();

                String property = s;

                Path path = genericDAO.extractPath(from, joins, property);
                Predicate predicate = null;
                Expression literal = null;
                switch (operator) {
                case NONE:
                    break;
                case EQUAL:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.equal(path, literal);
                    break;

                case IN:

                    throw new UnsupportedOperationException("IN not suppported");

                case LE:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.lessThanOrEqualTo(path, literal);
                    break;
                case LIKE:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.like(path, literal);
                    break;
                case ULIKE:
                    literal = criteriaBuilder.upper(criteriaBuilder.literal((String) value));
                    predicate = criteriaBuilder.like(criteriaBuilder.upper(path), literal);
                    break;
                case LT:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.lessThan(path, literal);
                    break;
                case GE:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.greaterThanOrEqualTo(path, literal);
                    break;

                case GT:
                    literal = criteriaBuilder.literal(value);
                    predicate = criteriaBuilder.greaterThan(path, literal);
                    break;

                case BETWEEN:
                    if (value instanceof Object[]) {
                        Object objs[] = (Object[]) value;
                        if (objs[0] != null && objs[1] != null) {
                            literal = criteriaBuilder.literal(objs[0]);
                            Expression literal2 = criteriaBuilder.literal(objs[1]);
                            predicate = criteriaBuilder.between(path, literal, literal2);

                            // criteriaQuery.where(criteriaBuilder.between((Expression<Number>)path,
                            // (Number) objs[0],(Number) objs[1]));
                        } else if (objs[0] != null) {
                            literal = criteriaBuilder.literal(objs[0]);
                            predicate = criteriaBuilder.greaterThanOrEqualTo(path, literal);

                        } else if (objs[1] != null) {
                            literal = criteriaBuilder.literal(objs[0]);
                            predicate = criteriaBuilder.lessThanOrEqualTo(path, literal);
                        }
                    }

                }
                if (predicate != null) {
                    if (daoMethodParameter.not())
                        predicate = predicate.not();
                    if (rootPredicate == null) {
                        rootPredicate = predicate;
                    } else {
                        rootPredicate = criteriaBuilder.and(rootPredicate, predicate);
                    }
                } else {
                    throw new UnsupportedOperationException("on handled operator:" + operator);
                }

            }

        }
        if (rootPredicate != null)
            criteriaQuery.where(rootPredicate);
        return joins;
    }

    /**
     * selct root object or selected fields
     * @param criteriaQuery query
     * @param from from root
     * @param method executed method
     * @return selection
     */
    private CriteriaQuery<Object> selectResult(CriteriaQuery<Object> criteriaQuery, Root from, Method method) {
        YMethod annotation = method.getAnnotation(YMethod.class);
        if (annotation == null || annotation.select() == null || annotation.select().length == 0)
            return criteriaQuery.select(from);
        ArrayList<Selection> selection = new ArrayList<Selection>();
        String[] select = annotation.select();
        for (String property : select) {
            String[] split = Utils.splitDot(property);
            Path p = from;
            for (String string : split) {
                p = p.get(string);
            }
            selection.add(p);
        }
        return criteriaQuery.multiselect(selection.toArray(new Selection[0]));

    }

    /**
     * add fetch statements for select
     * @param from root from
     * @param joins joins map
     * @param method target methods
     */
    private void fetchStatements(Root from, Method method) {
        YFetch daoFetch = method.getAnnotation(YFetch.class);
        if (daoFetch == null)
            daoFetch = (YFetch) objectClass.getAnnotation(YFetch.class);
        if (daoFetch != null) {
            String[] fetchProperties = daoFetch.value();
            HashMap fetchs = new HashMap();
            for (String s : fetchProperties) {
                String[] split = Utils.splitDot(s);

                genericDAO.getLatestFetch(from, fetchs, split);

            }

        }
    }

    /*
     * (non-Javadoc)
     * 
     * @see
     * com.altuure.yagdao.common.GenericDAOFactory#queryMethod(java.lang.Object,
     * java.lang.reflect.Method, java.lang.Object[])
     */
    protected Object queryMethod(Object obj, Method method, Object[] args) {
        YMethod daoMethod = method.getAnnotation(YMethod.class);

        YLimit paging = findDAOPage(method, args);

        Query query = createQuery(daoMethod);
        setQueryParameters(method, args, query);
        @SuppressWarnings("rawtypes")
        SearchResultList resultList = genericDAO.query(query, paging);
        if (objectClass.equals(method.getReturnType())) {
            if (resultList.isEmpty())
                return null;
            if (resultList.size() == 1)
                return resultList.get(0);
            else
                throw new NonUniqueResultException("total size:" + resultList.size());
        }

        return resultList;

    }

    /**
     * Set query parameters from query parameter annotaions
     * @param method Query method
     * @param args arguments
     * @param query query
     */
    private void setQueryParameters(Method method, Object[] args, Query query) {
        final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
        for (int i = 0; i < parameterAnnotations.length; i++) {
            final Annotation[] annotations = parameterAnnotations[i];
            final Object arg = args[i];
            YParameter daoMethodParameter = null;
            for (final Annotation annotation1 : annotations) {
                if (annotation1 instanceof YParameter) {
                    daoMethodParameter = (YParameter) annotation1;
                    break;
                }
            }
            // check if parameter if mapped
            if (daoMethodParameter != null) {
                final String name = daoMethodParameter.value();
                query.setParameter(name, arg);
            } else if (!(arg instanceof YLimit)) {
                query.setParameter(i + 1, arg);
            }

        }
    }

    /**
     * Create Query from method annotations
     * @param daoMethod method annotation
     * @return query from annotations
     */
    private Query createQuery(YMethod daoMethod) {
        Query query = null;
        if (!Utils.isEmpty(daoMethod.query()))
            query = getEntityManager().createQuery(daoMethod.query());
        else if (!Utils.isEmpty(daoMethod.queryName())) {
            query = getEntityManager().createNamedQuery(daoMethod.queryName());
        } else {
            throw new IllegalArgumentException("queryName or query must be defined");
        }
        return query;
    }

    protected Object saveMethod(Object obj, Method method, Object[] args) throws InstantiationException,
            IllegalAccessException, InvocationTargetException, NoSuchMethodException {

        Object o = objectClass.newInstance();
        setValues(o, method, args);
        getEntityManager().persist(o);
        return o;
    }

    protected Object updateMethod(Object obj, Method method, Object[] args) throws InstantiationException,
            IllegalAccessException, InvocationTargetException, NoSuchMethodException {
        Object o = genericDAO.load((Serializable) args[0]);
        setValues(o, method, args);
        getEntityManager().merge(o);
        getEntityManager().flush();
        return o;
    }

    protected Object countMethod(Object obj, Method method, Object[] args) {
        CriteriaBuilder criteriaBuilder = getEntityManager().getCriteriaBuilder();
        CriteriaQuery<Object> criteriaQuery = criteriaBuilder.createQuery();
        Root from = criteriaQuery.from(objectClass);

        setPredicates(method, args, from, criteriaBuilder, criteriaQuery);

        Expression minExpression = criteriaBuilder.count(from.get(genericDAO.getIdProperty()));

        CriteriaQuery<Object> select = criteriaQuery.select(minExpression);

        TypedQuery<Object> typedQuery = getEntityManager().createQuery(select);

        return typedQuery.getSingleResult();

    }

    protected Object deleteMethod(Object obj, Method method, Object[] args) {
        Object o = getEntityManager().getReference(objectClass, (Serializable) args[0]);
        getEntityManager().remove(o);
        return o;
    }

    public void setObjectClass(Class objectClass) {
        this.objectClass = objectClass;
    }

    public String toString() {
        return GenericJpaDAOFactory.class.getName() + ":" + objectClass;
    }

    public void setEntityManagerAccessor(EntityManagerAccessor entityManagerAccessor) {
        this.entityManagerAccessor = entityManagerAccessor;

    }

    /**
     * create new Instance of given interface or abstract class
     * @param clz interface
     * @param entityManagerAccessor  utility_
     * @return instance of given interface
     * @throws Exception creation exception
     */
    public static Object createInstance(Class clz, EntityManagerAccessor entityManagerAccessor) throws Exception {
        GenericJpaDAOFactory daoFactory = new GenericJpaDAOFactory();
        daoFactory.setTargetInterfaces(clz);
        daoFactory.setEntityManagerAccessor(entityManagerAccessor);
        daoFactory.initDAO();
        return daoFactory.getObject();
    }

}
